<?php

namespace Cephalopod;

use Exception;

class Select
{
    const EVENT_TYPE_READ = 0;

    const EVENT_TYPE_WRITE = 1;

    const EVENT_TYPE_CLOSE = 2;

    const EVENT_TYPE_OPEN = 3;

    const SOCKET_KEEPALIVE_SECOND = 120;

    const CYCLE_TIMEOUT_MICROSECOND = 1000;

    private array $socketIdAndSocketMap = [];

    private array $socketIdAndCallbackMap = [];

    private array $socketIdAndLastActivityMap = [];

    private array $socketIdWillClose = [];

    private array $resourceIdAndSocketIdMap = [];

    private int $autoincrementId = 1;

    public function createServer(string $ipAddress, int $listenPort)
    {
        $serverInfo = "tcp://{$ipAddress}:{$listenPort}";
        $context = stream_context_create(['socket' => ['tcp_nodelay' => true]]);
        $serverSocket = stream_socket_server($serverInfo, $errorCode, $errorMessage, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $context);
        if (false === $serverSocket) {
            throw new Exception("Failed to create the server. Error code: $errorCode, error message: $errorMessage. Server info: $serverInfo");
        }
        return $this->configure($serverSocket);
    }

    public function createClient(string $host, int $port)
    {
        $serverInfo = "tcp://$host:$port";
        $serverSocket = @stream_socket_client($serverInfo, $errorCode, $errorMessage, 0, STREAM_CLIENT_ASYNC_CONNECT);
        if (false === $serverSocket) {
            throw new Exception("Failed to connect the remote server. Error code: $errorCode, error message: $errorMessage. Server info: $serverInfo");
            return false;
        }
        return $this->configure($serverSocket);
    }

    public function mainLoop(): bool
    {
        if (empty($this->socketIdAndSocketMap)) {
            return true;
        }
        $except = null;
        $read = $write = $this->socketIdAndSocketMap;
        $total = stream_select($read, $write, $except, 0, self::CYCLE_TIMEOUT_MICROSECOND);
        if (false === $total || $total <= 0) {
            return false;
        }
        foreach ($read as $socket) {
            $this->doSocketEvent($socket, self::EVENT_TYPE_READ);
        }
        foreach ($write as $socket) {
            $this->doSocketEvent($socket, self::EVENT_TYPE_WRITE);
        }
        $now = time();
        foreach ($this->socketIdAndLastActivityMap as $socketId => $lastActivityTime) {
            if ($now - $lastActivityTime > self::SOCKET_KEEPALIVE_SECOND) {
                $this->cleanSocket($socketId);
            }
        }
        foreach ($this->socketIdWillClose as $socketId) {
            if (isset($this->socketIdAndSocketMap[$socketId])) {
                $this->cleanSocket($socketId);
            }
        }
        $this->socketIdWillClose = [];
        return false;
    }

    public function getSocketId($socket): int
    {
        return $this->resourceIdAndSocketIdMap[$this->getPhpResourceId($socket)] ?? -1;
    }

    public function add($socket, $callback): int
    {
        if (!is_resource($socket)) {
            throw new Exception('Error, $socket must be resource');
        }
        if (PHP_INT_MAX == $this->autoincrementId) {
            $this->autoincrementId = 1;
        }
        $socketId = $this->autoincrementId++;
        $this->resourceIdAndSocketIdMap[$this->getPhpResourceId($socket)] = $socketId;
        $this->socketIdAndSocketMap[$socketId] = $socket;
        $this->socketIdAndCallbackMap[$socketId] = $callback;
        $this->socketIdAndLastActivityMap[$socketId] = time();
        return $socketId;
    }

    public function willClose(int $socketId)
    {
        if (!isset($this->socketIdAndSocketMap[$socketId])) {
            return;
        }
        $this->socketIdWillClose[] = $socketId;
    }

    public function getNumberOfSockets(): int
    {
        return count($this->socketIdAndSocketMap);
    }

    public function __destruct()
    {
        foreach ($this->socketIdAndSocketMap as $sock) {
            stream_socket_shutdown($sock, STREAM_SHUT_RDWR);
        }
    }

    private function getPhpResourceId($streamResource): int
    {
        return version_compare(PHP_VERSION, '8.0.0') >= 0 ? get_resource_id($streamResource) : intval($streamResource);
    }

    private function cleanSocket(int $socketId)
    {
        $resourceId = $this->getPhpResourceId($this->socketIdAndSocketMap[$socketId]);
        call_user_func($this->socketIdAndCallbackMap[$socketId], $this->socketIdAndSocketMap[$socketId], self::EVENT_TYPE_CLOSE, $this);
        stream_socket_shutdown($this->socketIdAndSocketMap[$socketId], STREAM_SHUT_RDWR);
        unset($this->resourceIdAndSocketIdMap[$resourceId]);
        unset($this->socketIdAndSocketMap[$socketId]);
        unset($this->socketIdAndCallbackMap[$socketId]);
        unset($this->socketIdAndLastActivityMap[$socketId]);
    }

    private function doSocketEvent($socket, int $eventType)
    {
        if (!is_resource($socket)) {
            return;
        }
        $socketId = $this->getSocketId($socket);
        if ($socketId < 0) {
            return;
        }
        if (feof($socket)) {
            $this->cleanSocket($socketId);
            return;
        }
        if (self::EVENT_TYPE_READ == $eventType) {
            $this->socketIdAndLastActivityMap[$socketId] = time();
        }
        if (!call_user_func($this->socketIdAndCallbackMap[$socketId], $socket, $eventType, $this)) {
            $this->cleanSocket($socketId);
        }
    }

    private function configure($serverSocket)
    {
        //For some reason when writing (in total) ~256k, sockets start returning
        //FALSE when reading, yet always appear in the stream_select arrays.
        //This fixed that problem. (for us.)
        if (0 !== stream_set_read_buffer($serverSocket, 0)) {
            throw new Exception('Cannot set read buffer size to 0');
        }
        //For some reason, the write buffer cannot be set:
        //    Function stream_set_write_buffer() returns 0 on success, or
        //    another value if the request cannot be honored. But the request
        //    always cannot be honored.
        //
        //if (0 !== stream_set_write_buffer($serverSocket, 0)) {
        //    $this->logError('Cannot set write buffer size to 0.');
        //    return false;
        //}
        if (false === stream_set_blocking($serverSocket, false)) {
            throw new Exception('Failed to set to non blocking mode');
        }
        //The socket functions described here are part of an extension to PHP
        //which must be enabled at compile time by giving the --enable-sockets
        //option to configure. Otherwise, the following properties cannot be set.
        if (function_exists('socket_import_stream')) {
            if (false === ($rawSocket = socket_import_stream($serverSocket))) {
                throw new Exception('Can not imports a stream');
            }
            if (
                !socket_set_option($rawSocket, SOL_SOCKET, SO_KEEPALIVE, 1)
                || !socket_set_option($rawSocket, SOL_SOCKET, SO_LINGER, ['l_onoff' => 1, 'l_linger' => 0])
            ) {
                $this->logError('socket_set_option() false');
                return false;
            }
        }
        return $serverSocket;
    }
}
